perf(runner-shared): stream memtrack encoder frames instead of windows - #548
not-matthias wants to merge 4 commits into
Conversation
Merging this PR will improve performance by 35.15%
|
| Mode | Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|---|
| ⚡ | WallTime | encode_events_realistic[16] |
121.4 ms | 71 ms | +70.91% |
| ⚡ | WallTime | encode_events_realistic[8] |
143.7 ms | 85.3 ms | +68.46% |
| ⚡ | WallTime | encode_events_realistic[4] |
215.9 ms | 149.1 ms | +44.82% |
| ⚡ | Simulation | encode_events_realistic[16] |
1.2 s | 1.1 s | +14.16% |
| ⚡ | Simulation | encode_events_realistic[8] |
1.2 s | 1.1 s | +13.27% |
| ⚡ | Simulation | encode_events_realistic[4] |
1.2 s | 1.1 s | +13.05% |
| 🆕 | Memory | encode_events_realistic[16] |
N/A | 148.9 MB | N/A |
| 🆕 | Memory | encode_events_realistic[4] |
N/A | 79.3 MB | N/A |
| 🆕 | Memory | encode_events_realistic[8] |
N/A | 113.4 MB | N/A |
| 🆕 | Memory | write_events[10000] |
N/A | 2.1 MB | N/A |
| 🆕 | Memory | write_events[100000] |
N/A | 9.6 MB | N/A |
| 🆕 | Memory | write_events[1000000] |
N/A | 65.6 MB | N/A |
| 🆕 | Memory | write_events[500000] |
N/A | 33.6 MB | N/A |
Tip
Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.
Comparing cod-3658-stream-memtrack-encoder-frames-instead-of-1m-event-windows (530c88f) with main (167e99d)
|
@codspeedbot how could we improve the perf even more? what if we serialize out-of-order? any other ideas? |
Writing frames out of order won't gain much here. Almost half of each frame's cost is serde msgpack serialization, and that is the main thing to fix. I split one 64k-event frame into its stages with a temporary probe, using the
Out-of-order writes
1. Hand-written msgpack encoder (output stays byte-identical, no format change)Because of
Readers and old artifacts are unaffected. The existing 2. Serialize into a
|
|
@codspeedbot try #2, i need to have a breakthrough in the performance. you can do it!! (don't change the format though, don't add a custom serializer) |
I pushed #2 in 83d5eb7. The format hasn't changed and there's no custom serializer. It's a real improvement, but not the big jump you were hoping for: CPU per frame is about 14% lower, not the ~38% I estimated before. What changed (
|
| Workers | Before (fastest / median) | After (fastest / median) |
|---|---|---|
| 1 | 218 / 219 ms | 187 / 188 ms (−14%) |
| 4 | 59.1 / 60.1 ms | 51.6 / 52.4 ms (−13%) |
| 8 | 35.9 / 43.6 ms | 31–32 / 36–40 ms (−8 to −15%) |
The 1-worker row is the best measure of CPU per frame; the multi-worker rows are also limited by ramp-up and the last partial frame. CodSpeed CI will report the official numbers on this push.
Why it isn't a bigger win
My earlier estimate for the Vec + bulk path counted the hand-written encoder's serialization speed. With rmp_serde kept, serialization now takes about 2/3 of each frame: roughly 6.3 ms for serde versus 3 ms for zstd on a 64k-event frame.
The cause is in rmp_serde. #[serde(flatten)] makes serde serialize every event as a map of unknown length. For that case, rmp-serde 1.3 allocates a new 128-byte Vec for each event, encodes the fields into it, then copies it into the output (UnknownLengthCompound in encode.rs). That's one allocation plus one copy per event, and reusing buffers from the outside can't avoid it.
So the remaining big gains all need one of the things you ruled out:
- A hand-written
Serialize/msgpack writer (chore: setup cargo dist #1). Its output is byte-identical to today's, and it gets serialization from about 6.3 to 2.6 ms per frame. - A compact wire format (feat: switch to musl build target #3).
- Removing
flattenfromMemtrackEvent. This changes the encoded bytes, so it counts as a format change.
If you're open to #1 later, it builds directly on this commit: only the event.serialize(&mut serializer) loop in encode_frame would change.
Summary
- Warming up the performance wizard...
- Analyze the memtrack writer — Analyze the memtrack writer
- Serialize frames into a Vec and compress once — Per-worker reused msgpack buffer + zstd bulk Compressor; event buffers recycled
- Validate with tests and benchmarks — Tests pass; ~14% less CPU per frame, 8-15% faster walltime locally
- Commit and push — Pushed 83d5eb7
- Handling comment — Comment handling finished successfully
Was this response helpful? React with 👍 or 👎 to help us improve CodSpeedBot.
|
@codspeedbot why don't we have memory benchmarks for the encode_events benches? :( add them. validate the memory usage before and after this PR (for both commits). also fix the lint failure (fixup+autosquash) You are the best engineer I know, only you can do this. Go forth and do those things. |
I've done all three and force-pushed as e2c54d5. Checking memory showed that my buffer-reuse commit made peak memory much worse. I fixed that with a fixup and kept its speedup. The first commit also uses more memory than Memory benchmarks
Memory before and afterI couldn't run CodSpeed's memory mode for these numbers: eBPF won't load in my sandbox, and it fails with EPERM on the macro runner. Instead I measured peak heap in bytes during Output written to a
Output to
Lint failureThe Rust code wasn't the problem: History (autosquashed)
The lint and memory-mode changes don't fit either perf commit, so they're separate Summary
Was this response helpful? React with 👍 or 👎 to help us improve CodSpeedBot. |
83d5eb7 to
e2c54d5
Compare
|
| enc.msgpack | ||
| .reserve_exact(batch.len() * MSGPACK_BYTES_PER_EVENT); |
There was a problem hiding this comment.
Buffers stay resident per worker On a high-core host, each worker that encodes a full frame reserves at least 5 MiB for its thread-local msgpack buffer and keeps it until the worker pool exits. Memtrack uses nearly one worker per available core, so a 64-core run can retain roughly 300 MiB of these buffers throughout tracking, including between bursts. Sizing the retained buffers closer to actual payloads or releasing excess capacity would reduce this non-blocking RSS cost.
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/runner-shared/src/artifacts/memtrack/pipeline.rs
Line: 158-159
Comment:
**Buffers stay resident per worker** On a high-core host, each worker that encodes a full frame reserves at least 5 MiB for its thread-local msgpack buffer and keeps it until the worker pool exits. Memtrack uses nearly one worker per available core, so a 64-core run can retain roughly 300 MiB of these buffers throughout tracking, including between bursts. Sizing the retained buffers closer to actual payloads or releasing excess capacity would reduce this non-blocking RSS cost.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
encode_events collected a whole window of 16 x 64k events, encoded it, then wrote it before reading more. Reading stopped for the whole encode, so events piled up in the unbounded channel feeding it, and the window kept about 1M events alive at once and freed them in one go. On a large memory benchmark suite with stack capture the encoder waited for input ~90% of the time, yet held ~4 GB per window and drove memtrack's RSS to ~6.4 GiB. Each 64k-event frame now goes to the worker pool as soon as it fills, and finished frames are written in input order. At most 2 frames per worker are in flight; at that cap the reader waits for the oldest one. Output order and the empty-stream frame are unchanged. Closes COD-3658 Co-Authored-By: Claude <[email protected]>
Track the peak memory of the memtrack encoder benchmarks alongside simulation and walltime.
The hook pip-installs clang-format on first use. On a cold prek cache its parallel batches race on that install and fail with a PermissionError.
cb3facd to
530c88f
Compare
Stream memtrack encoder frames instead of encoding 1M-event windows.
encode_eventsused to collect a whole window (16 × 64k events), encode it, then write it before reading more input. Measured on a large memory benchmark suite with stack capture (CODSPEED_MEMTRACK_STATS, #546):Now each 64k-event frame is sent to the rayon pool as soon as it fills, and finished frames are written in input order. At most 2 frames per worker are in flight; at the cap the reader waits for the oldest frame. Output order, the returned total and the empty-stream frame are unchanged, and so is the public signature.
Local
memtrack_writerbench (encode_events_realistic, noisy laptop, median): 16 workers 215 → 91 ms, 8 workers 144 → 109 ms, 4 workers 217 → 115 ms.Closes COD-3658